翻译|Getting Started with React, Redux and Immutable a Test-Driven Tutorial (Part 2)

翻译版本,原文请见

这是第二部分的内容.

在第一部分,我们罗列了app的UI,开发和单元测试的基础.

我们看到了app的state通过React的props向下传递到单个的组件,用户的actions声明为回调函数,因此app的逻辑和UI分离开来了.

Redux的工作流介绍

在这一点上,我们的UI是没有交互操作的:尽管我们已经测试了如果一个item如果被设定为completed,它将给文本划线,但是这里还没有方法邀请用户来完成它:

  1. state tree通过props定义了UI和action回调函数.
  2. 用户的actions,例如点击,被发送到action creator,action被它范式化.
  3. redux action被传递到reducer实现实际的app逻辑
  4. reducer更新state tree,dispatch state到store.
  5. UI根据store里的新state tree来更新UI

Redux working flos

设定初始化state

这部分的代码提交在这里

我们的第一个action将会允许我们在Redux store里正确的设置初始化state
,我们将会创建store.

Redux中的action是一个信息的载体(payload).action由一个JSON对象有一个type属性,描述action到底是做什么的,还有一部分是app需要的信息.在我们的实例中,type被设定为SET_STATE,我们可以添加一个state对象包含需要的state:

1
2
3
4
5
6
7
8
9
10
11
{
type: 'SET_STATE',
state: {
todos: [
{id: 1, text: 'React', status: 'active', editing: false},
{id: 2, text: 'Redux', status: 'active', editing: false},
{id: 3, text: 'Immutable', status: 'active', editing: false},
],
filter: 'all'
}
}

这个action会被dispatch到一个reducer,reducer角色的是识别和实施和action对应的逻辑代码.

让我们为reducer来写单元测试代码
test/reducer_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {

it('handles SET_STATE', () => {
const initialState = Map();
const action = {
type: 'SET_STATE',
state: Map({
todos: List.of(
Map({id: 1, text: 'React', status: 'active'}),
Map({id: 2, text: 'Redux', status: 'active'}),
Map({id: 3, text: 'Immutable', status: 'completed'})
)
})
};

const nextState = reducer(initialState, action);

expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'active'},
{id: 3, text: 'Immutable', status: 'completed'}
]
}));
});

});

为了方便一点,state使用单纯JS对象,而不是使用Immutable数据结构.让我们的reducer来处理转变.最后,reducer将会优雅的处理undefined初始化state:
test/reducer_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
 // ...
describe('reducer', () => {
// ...
it('handles SET_STATE with plain JS payload', () => {
const initialState = Map();
const action = {
type: 'SET_STATE',
state: {
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'active'},
{id: 3, text: 'Immutable', status: 'completed'}
]
}
};
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'active'},
{id: 3, text: 'Immutable', status: 'completed'}
]
}));
});

it('handles SET_STATE without initial state', () => {
const action = {
type: 'SET_STATE',
state: {
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'active'},
{id: 3, text: 'Immutable', status: 'completed'}
]
}
};
const nextState = reducer(undefined, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'active'},
{id: 3, text: 'Immutable', status: 'completed'}
]
}));
});
});

我们的reducer将会匹配接收的actions的type,如果type是SET_STATE,当前的state和action运载的state融合在一起:
src/reducer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import {Map} from 'immutable';

function setState(state, newState) {
return state.merge(newState);
}

export default function(state = Map(), action) {
switch (action.type) {
case 'SET_STATE':
return setState(state, action.state);
}
return state;
}

现在我们不得不把reducer连接到我们的app,所以当app启动初始化state.这里实际是第一次使用Redux库,安装一下
npm install —save redux@3.3.1 react-redux@4.4.1

src/index.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 import React from 'react';
import ReactDOM from 'react-dom';
import {List, Map} from 'immutable';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import {TodoAppContainer} from './components/TodoApp';

// We instantiate a new Redux store
const store = createStore(reducer);
// We dispatch the SET_STATE action holding the desired state
store.dispatch({
type: 'SET_STATE',
state: {
todos: [
{id: 1, text: 'React', status: 'active', editing: false},
{id: 2, text: 'Redux', status: 'active', editing: false},
{id: 3, text: 'Immutable', status: 'active', editing: false},
],
filter: 'all'
}
});

require('../node_modules/todomvc-app-css/index.css');

ReactDOM.render(
// We wrap our app in a Provider component to pass the store down to the components
<Provider store={store}>
<TodoAppContainer />
</Provider>,
document.getElementById('app')
);

如果你看看上面的代码段,你可以注意到我们的TodoApp组件实际是被TodoAppContainer代替.在Redux里,有两种类型的组件:展示组件和容器.我推荐你阅读一下由Dan Abramov(Redux的作者)写作的高信息量的文章,强调了展示组件和容器的差异性.

如果我想总结得快一点,我将引用Redux 文档的内容:

“展示组件是关于事件的样子(模板和样式),容器组件是关于事情是怎么工作的(数据获取,state更新)”.

所以我们创建store,传递给TodoAppContainer.然而为了子组件可以使用store,我们把state映射成为React组件TodoAppprops.
src/components/TodoApp.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 // ...
import {connect} from 'react-redux';

export class TodoApp extends React.Component {
// ...
}
function mapStateToProps(state) {
return {
todos: state.get('todos'),
filter: state.get('filter')
};
}

export const TodoAppContainer = connect(mapStateToProps)(TodoApp);

如果你在浏览器中重新加载app,你应该可以看到它初始化和之前一样,不过现在使用Redux tools.

Redux dev 工具

这一部分的提交代码

现在我们已经配置了redux store和reducer.我们可以配置Redux dev tools来展现数据流开发.

首先,获取Redux dev tools Chrome extension

dev tools可以在Store创建的时候可以加载.

src/index.jsx

1
2
3
4
5
6
7
8
 // ...
import {compose, createStore} from 'redux';

const createStoreDevTools = compose(
window.devToolsExtension ? window.devToolsExtension() : f => f
)(createStore);
const store = createStoreDevTools(reducer);
// ...

Redux dev tools

重新加载app,点击Redux图标,有了.

有三个不同的监视器可以使用:Diff监视器,日志监视器,Slider监视器.

使用Action Creators配置我们的actions

切换item的不同状态.

这部分的提交代码在这里

下一步是允许用户在activecompleted之前切换状态:
test/reducer_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
 import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {
// ...
it('handles TOGGLE_COMPLETE by changing the status from active to completed', () => {
const initialState = fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'active'},
{id: 3, text: 'Immutable', status: 'completed'}
]
});
const action = {
type: 'TOGGLE_COMPLETE',
itemId: 1
}
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'completed'},
{id: 2, text: 'Redux', status: 'active'},
{id: 3, text: 'Immutable', status: 'completed'}
]
}));
});

it('handles TOGGLE_COMPLETE by changing the status from completed to active', () => {
const initialState = fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'active'},
{id: 3, text: 'Immutable', status: 'completed'}
]
});
const action = {
type: 'TOGGLE_COMPLETE',
itemId: 3
}
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'active'},
{id: 3, text: 'Immutable', status: 'active'}
]
}));
});
});

为了通过这些测试,我们更新reducer:
src/reducer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ...
function toggleComplete(state, itemId) {
// We find the index associated with the itemId
const itemIndex = state.get('todos').findIndex(
(item) => item.get('id') === itemId
);
// We update the todo at this index
const updatedItem = state.get('todos')
.get(itemIndex)
.update('status', status => status === 'active' ? 'completed' : 'active');

// We update the state to account for the modified todo
return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

export default function(state = Map(), action) {
switch (action.type) {
case 'SET_STATE':
return setState(state, action.state);
case 'TOGGLE_COMPLETE':
return toggleComplete(state, action.itemId);
}
return state;
}

SET_STATE的action同一个地方,我们需要让TodoAppContainer组件感知到action,所以toggleComplete回调函数会被传递到TodoItem组件(实际调用函数的地方).

在Redux中,有标准的方法来做这件事:Action Creators.

action creators是简单的函数,返回合适的action,这些韩式是React的props的一些映射之一.
让我们创建第一个action creator:
src/action_creators.js

1
2
3
4
5
6
export function toggleComplete(itemId) {
return {
type: 'TOGGLE_COMPLETE',
itemId
}
}

现在,尽管TodoAppcontainer组件中的connect函数的调用可以用来获取store,我们告诉组件使用映射props的回调函数:
src/components/TodoApp.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ...
import * as actionCreators from '../action_creators';
export class TodoApp extends React.Component {
// ...
render() {
return <div>
// ...
// We use the spread operator for better lisibility
<TodoList {...this.props} />
// ...
</div>
}
};

export const TodoAppContainer = connect(mapStateToProps, actionCreators)(TodoApp);

重启你的webserver,刷新一下你的浏览器:当当.在条目上点击现在可以切换它的状态.如果你查看Redux dev tools,你可以看到触发的action和后继的更新.

改变目前的过滤器

相关代码在在这里

现在每件事情都已经配置完毕,写其他的action是件小事.我们继续创建你希望的CHANGE_FILTERaction,改变当前state的filter,由此仅仅显示过滤过的条目.
开始创建action creator:
src/action_creators.js

1
2
3
4
5
6
7
 // ...
export function changeFilter(filter) {
return {
type: 'CHANGE_FILTER',
filter
}
}

现在写reducer的单元测试:
test/reducer_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ...
describe('reducer', () => {
// ...
it('handles CHANGE_FILTER by changing the filter', () => {
const initialState = fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
],
filter: 'all'
});
const action = {
type: 'CHANGE_FILTER',
filter: 'active'
}
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
],
filter: 'active'
}));
});
});

关联的reducer函数:
src/reducer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 // ...
function changeFilter(state, filter) {
return state.set('filter', filter);
}

export default function(state = Map(), action) {
switch (action.type) {
case 'SET_STATE':
return setState(state, action.state);
case 'TOGGLE_COMPLETE':
return toggleComplete(state, action.itemId);
case 'CHANGE_FILTER':
return changeFilter(state, action.filter);
}
return state;
}

最后我们把changeFilter回调函数传递给TodoTools组件:
TodoApp.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ...
export class TodoApp extends React.Component {
// ...
render() {
return <div>
<section className="todoapp">
// ...
<TodoTools changeFilter={this.props.changeFilter}
filter={this.props.filter}
nbActiveItems={this.getNbActiveItems()} />
</section>
<Footer />
</div>
}
};

完成了,第一个filter selector工作完美

Item编辑

代码在这里
当用户编辑一个条目,实际上是两个actions触发的三个可能性:

  • 用户输入编辑模式:EDIT_ITEM
  • 用户退出编辑模式(不保存变化):CANCEL_EDITING
  • 用户验证他的编辑(保存变化):DONE_EDITING

我们可以为三个actions编写action creators:
src/action_creators.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ...
export function editItem(itemId) {
return {
type: 'EDIT_ITEM',
itemId
}
}

export function cancelEditing(itemId) {
return {
type: 'CANCEL_EDITING',
itemId
}
}

export function doneEditing(itemId, newText) {
return {
type: 'DONE_EDITING',
itemId,
newText
}
}

现在为这些actions编写单元测试:
test/reducer_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// ...
describe('reducer', () => {
// ...
it('handles EDIT_ITEM by setting editing to true', () => {
const initialState = fromJS({
todos: [
{id: 1, text: 'React', status: 'active', editing: false},
]
});
const action = {
type: 'EDIT_ITEM',
itemId: 1
}
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active', editing: true},
]
}));
});

it('handles CANCEL_EDITING by setting editing to false', () => {
const initialState = fromJS({
todos: [
{id: 1, text: 'React', status: 'active', editing: true},
]
});
const action = {
type: 'CANCEL_EDITING',
itemId: 1
}
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active', editing: false},
]
}));
});

it('handles DONE_EDITING by setting by updating the text', () => {
const initialState = fromJS({
todos: [
{id: 1, text: 'React', status: 'active', editing: true},
]
});
const action = {
type: 'DONE_EDITING',
itemId: 1,
newText: 'Redux',
}
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'Redux', status: 'active', editing: false},
]
}));
});
});

现在我们可以开发reducer函数,实际操作三个actions:
src/reducer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function findItemIndex(state, itemId) {
return state.get('todos').findIndex(
(item) => item.get('id') === itemId
);
}

// We can refactor the toggleComplete function to use findItemIndex
function toggleComplete(state, itemId) {
const itemIndex = findItemIndex(state, itemId);
const updatedItem = state.get('todos')
.get(itemIndex)
.update('status', status => status === 'active' ? 'completed' : 'active');

return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

function editItem(state, itemId) {
const itemIndex = findItemIndex(state, itemId);
const updatedItem = state.get('todos')
.get(itemIndex)
.set('editing', true);

return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

function cancelEditing(state, itemId) {
const itemIndex = findItemIndex(state, itemId);
const updatedItem = state.get('todos')
.get(itemIndex)
.set('editing', false);

return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

function doneEditing(state, itemId, newText) {
const itemIndex = findItemIndex(state, itemId);
const updatedItem = state.get('todos')
.get(itemIndex)
.set('editing', false)
.set('text', newText);

return state.update('todos', todos => todos.set(itemIndex, updatedItem));
}

export default function(state = Map(), action) {
switch (action.type) {
// ...
case 'EDIT_ITEM':
return editItem(state, action.itemId);
case 'CANCEL_EDITING':
return cancelEditing(state, action.itemId);
case 'DONE_EDITING':
return doneEditing(state, action.itemId, action.newText);
}
return state;
}

清除完成,添加和删除条目

代码在这里

三个剩下的action是:

  1. CLEAR_COMPLETED,在TodoTools组件中触发,从列表中清除完成的条目
  2. ADD_ITEM,在TodoHeader中触发,根据用户的的输入文本来添加条目
  3. DELETE_ITEM,相似TodoItem中调用,删除一个条目

我们现在使用的工作流是:添加action creators,单元测试reducer和代码逻辑,最终通过props传递回调函数:
src/action_creators.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ...
export function clearCompleted() {
return {
type: 'CLEAR_COMPLETED'
}
}

export function addItem(text) {
return {
type: 'ADD_ITEM',
text
}
}

export function deleteItem(itemId) {
return {
type: 'DELETE_ITEM',
itemId
}
}

test/reducer_spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
 // ...
describe('reducer', () => {
// ...
it('handles CLEAR_COMPLETED by removing all the completed items', () => {
const initialState = fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'completed'},
]
});
const action = {
type: 'CLEAR_COMPLETED'
}
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
]
}));
});

it('handles ADD_ITEM by adding the item', () => {
const initialState = fromJS({
todos: [
{id: 1, text: 'React', status: 'active'}
]
});
const action = {
type: 'ADD_ITEM',
text: 'Redux'
}
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'active'},
]
}));
});

it('handles DELETE_ITEM by removing the item', () => {
const initialState = fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
{id: 2, text: 'Redux', status: 'completed'},
]
});
const action = {
type: 'DELETE_ITEM',
itemId: 2
}
const nextState = reducer(initialState, action);
expect(nextState).to.equal(fromJS({
todos: [
{id: 1, text: 'React', status: 'active'},
]
}));
});
});

src/reducer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function clearCompleted(state) {
return state.update('todos',
(todos) => todos.filterNot(
(item) => item.get('status') === 'completed'
)
);
}

function addItem(state, text) {
const itemId = state.get('todos').reduce((maxId, item) => Math.max(maxId,item.get('id')), 0) + 1;
const newItem = Map({id: itemId, text: text, status: 'active'});
return state.update('todos', (todos) => todos.push(newItem));
}

function deleteItem(state, itemId) {
return state.update('todos',
(todos) => todos.filterNot(
(item) => item.get('id') === itemId
)
);
}

export default function(state = Map(), action) {
switch (action.type) {
// ...
case 'CLEAR_COMPLETED':
return clearCompleted(state);
case 'ADD_ITEM':
return addItem(state, action.text);
case 'DELETE_ITEM':
return deleteItem(state, action.itemId);
}
return state;
}

src/components/TodoApp.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 // ...
export class TodoApp extends React.Component {
// ...
render() {
return <div>
<section className="todoapp">
// We pass down the addItem callback
<TodoHeader addItem={this.props.addItem}/>
<TodoList {...this.props} />
// We pass down the clearCompleted callback
<TodoTools changeFilter={this.props.changeFilter}
filter={this.props.filter}
nbActiveItems={this.getNbActiveItems()}
clearCompleted={this.props.clearCompleted}/>
</section>
<Footer />
</div>
}
};

我们的TodoMVC app现在完成了.

包装起来

这我们的测试驱动的React,Redux&Immutable 技术栈

如果你想了解更多内容,有更多的事情等着你去挖掘
例如: